package uk.org.okapibarcode.backend; import static java.lang.Integer.toHexString; import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import java.awt.Color; import java.awt.Font; import java.awt.FontFormatException; import java.awt.Graphics2D; import java.awt.GraphicsEnvironment; import java.awt.image.BufferedImage; import java.io.File; import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStream; import java.io.PrintWriter; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.nio.ByteBuffer; import java.nio.CharBuffer; import java.nio.charset.CharacterCodingException; import java.nio.charset.Charset; import java.nio.charset.CharsetDecoder; import java.nio.charset.CodingErrorAction; import java.nio.file.Files; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import javax.imageio.ImageIO; import org.junit.Assert; import org.junit.ComparisonFailure; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; import org.junit.runners.Parameterized.Parameters; import org.reflections.Reflections; import uk.org.okapibarcode.output.Java2DRenderer; import com.google.zxing.BinaryBitmap; import com.google.zxing.DecodeHintType; import com.google.zxing.LuminanceSource; import com.google.zxing.Reader; import com.google.zxing.ReaderException; import com.google.zxing.Result; import com.google.zxing.client.j2se.BufferedImageLuminanceSource; import com.google.zxing.common.HybridBinarizer; import com.google.zxing.oned.CodaBarReader; import com.google.zxing.oned.Code39Reader; import com.google.zxing.oned.Code93Reader; import com.google.zxing.oned.EAN13Reader; import com.google.zxing.oned.EAN8Reader; import com.google.zxing.oned.UPCAReader; import com.google.zxing.oned.UPCEReader; import com.google.zxing.pdf417.PDF417Reader; import com.google.zxing.qrcode.QRCodeReader; /** * <p> * Scans the test resources for file-based bar code tests. * * <p> * Tests that verify successful behavior will contain the following sets of files: * * <pre> * /src/test/resources/uk/org/okapibarcode/backend/[symbol-name]/[test-name].properties (bar code initialization attributes) * /src/test/resources/uk/org/okapibarcode/backend/[symbol-name]/[test-name].codewords (expected intermediate coding of the bar code) * /src/test/resources/uk/org/okapibarcode/backend/[symbol-name]/[test-name].png (expected final rendering of the bar code) * </pre> * * <p> * Tests that verify error conditions will contain the following sets of files: * * <pre> * /src/test/resources/uk/org/okapibarcode/backend/[symbol-name]/[test-name].properties (bar code initialization attributes) * /src/test/resources/uk/org/okapibarcode/backend/[symbol-name]/[test-name].error (expected error message) * </pre> * * <p> * If a properties file is found with no matching expectation files, we assume that it was recently added to the test suite and * that we need to generate suitable expectation files for it. * * <p> * A single properties file can contain multiple test configurations (separated by an empty line), as long as the expected output * is the same for all of those tests. */ @RunWith(Parameterized.class) public class SymbolTest { /** The font used to render human-readable text when drawing the symbologies; allows for consistent results across operating systems. */ private static final Font DEJA_VU_SANS; static { String path = "/uk/org/okapibarcode/fonts/OkapiDejaVuSans.ttf"; try { InputStream is = SymbolTest.class.getResourceAsStream(path); DEJA_VU_SANS = Font.createFont(Font.TRUETYPE_FONT, is); GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment(); boolean registered = ge.registerFont(DEJA_VU_SANS); assertTrue("Unable to register test font!", registered); } catch (IOException | FontFormatException e) { throw new RuntimeException(e.getMessage(), e); } } /** The type of symbology being tested. */ private final Class< ? extends Symbol > symbolType; /** The test configuration properties. */ private final Map< String, String > properties; /** The file containing the expected intermediate coding of the bar code, if this test verifies successful behavior. */ private final File codewordsFile; /** The file containing the expected final rendering of the bar code, if this test verifies successful behavior. */ private final File pngFile; /** The file containing the expected error message, if this test verifies a failure. */ private final File errorFile; /** * Creates a new test. * * @param symbolType the type of symbol being tested * @param properties the test configuration properties * @param codewordsFile the file containing the expected intermediate coding of the bar code, if this test verifies successful behavior * @param pngFile the file containing the expected final rendering of the bar code, if this test verifies successful behavior * @param errorFile the file containing the expected error message, if this test verifies a failure * @param symbolName the name of the symbol type (used only for test naming) * @param fileBaseName the base name of the test file (used only for test naming) * @throws IOException if there is any I/O error */ public SymbolTest(Class< ? extends Symbol > symbolType, Map< String, String > properties, File codewordsFile, File pngFile, File errorFile, String symbolName, String fileBaseName) throws IOException { this.symbolType = symbolType; this.properties = properties; this.codewordsFile = codewordsFile; this.pngFile = pngFile; this.errorFile = errorFile; } /** * Runs the test. If there are no expectation files yet, we generate them instead of checking against them. * * @throws Exception if any error occurs during the test */ @Test public void test() throws Exception { Symbol symbol = symbolType.newInstance(); symbol.setFontName(DEJA_VU_SANS.getFontName()); try { setProperties(symbol, properties); } catch (InvocationTargetException e) { symbol.error_msg = e.getCause().getMessage(); // TODO: migrate completely to exceptions? } if (codewordsFile.exists() && pngFile.exists()) { verifySuccess(symbol); } else if (errorFile.exists()) { verifyError(symbol); } else { generateExpectationFiles(symbol); } } /** * Verifies that the specified symbol was encoded and rendered in a way that matches expectations. * * @param symbol the symbol to check * @throws IOException if there is any I/O error * @throws ReaderException if ZXing has an issue decoding the barcode image */ private void verifySuccess(Symbol symbol) throws IOException, ReaderException { assertEquals("error message", "", symbol.error_msg); List< String > expectedList = Files.readAllLines(codewordsFile.toPath(), UTF_8); try { // try to verify codewords int[] actualCodewords = symbol.getCodewords(); assertEquals(expectedList.size(), actualCodewords.length); for (int i = 0; i < actualCodewords.length; i++) { int expected = getInt(expectedList.get(i)); int actual = actualCodewords[i]; assertEquals("at codeword index " + i, expected, actual); } } catch (UnsupportedOperationException e) { // codewords aren't supported, try to verify patterns String[] actualPatterns = symbol.pattern; assertEquals(expectedList.size(), actualPatterns.length); for (int i = 0; i < actualPatterns.length; i++) { String expected = expectedList.get(i); String actual = actualPatterns[i]; assertEquals("at pattern index " + i, expected, actual); } } // make sure the barcode images match BufferedImage expected = ImageIO.read(pngFile); BufferedImage actual = draw(symbol); assertEqual(expected, actual); // if possible, ensure an independent third party (ZXing) can read the generated barcode and agrees on what it represents Reader zxingReader = findReader(symbol); if (zxingReader != null) { LuminanceSource source = new BufferedImageLuminanceSource(expected); BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source)); Map< DecodeHintType, Boolean > hints = Collections.singletonMap(DecodeHintType.PURE_BARCODE, Boolean.TRUE); Result result = zxingReader.decode(bitmap, hints); String zxingData = removeChecksum(result.getText(), symbol); String okapiData = removeStartStopChars(symbol.getContent(), symbol); assertEquals("checking against ZXing results", okapiData, zxingData); } } /** * Returns a ZXing reader that can read the specified symbol. * * @param symbol the symbol to be read * @return a ZXing reader that can read the specified symbol */ private static Reader findReader(Symbol symbol) { if (symbol instanceof Code93) { return new Code93Reader(); } else if (symbol instanceof Code3Of9) { return new Code39Reader(); } else if (symbol instanceof Codabar) { return new CodaBarReader(); } else if (symbol instanceof QrCode) { return new QRCodeReader(); } else if (symbol instanceof Ean) { Ean ean = (Ean) symbol; if (ean.getMode() == Ean.Mode.EAN8) { return new EAN8Reader(); } else { return new EAN13Reader(); } } else if (symbol instanceof Pdf417) { Pdf417 pdf417 = (Pdf417) symbol; if (pdf417.getMode() != Pdf417.Mode.MICRO) { return new PDF417Reader(); } } else if (symbol instanceof Upc) { Upc upc = (Upc) symbol; if (upc.getMode() == Upc.Mode.UPCA) { return new UPCAReader(); } else { return new UPCEReader(); } } // no corresponding ZXing reader exists, or it behaves badly so we don't use it for testing return null; } /** * Removes the checksum from the specified barcode content, according to the type of symbol that encoded the content. * * @param s the barcode content * @param symbol the symbol which encoded the content * @return the barcode content, without the checksum */ private static String removeChecksum(String s, Symbol symbol) { if (symbol instanceof Ean || symbol instanceof Upc) { return s.substring(0, s.length() - 1); } else { return s; } } /** * Removes the start/stop characters from the specified barcode content, according to the type of symbol that encoded the * content. * * @param s the barcode content * @param symbol the symbol which encoded the content * @return the barcode content, without the start/stop characters */ private static String removeStartStopChars(String s, Symbol symbol) { if (symbol instanceof Codabar) { return s.substring(1, s.length() - 1); } else { return s; } } /** * Verifies that the specified symbol encountered the expected error during encoding. * * @param symbol the symbol to check * @throws IOException if there is any I/O error */ private void verifyError(Symbol symbol) throws IOException { String expectedError = Files.readAllLines(errorFile.toPath(), UTF_8).get(0); assertEquals(expectedError, symbol.error_msg); } /** * Generates the expectation files for the specified symbol. * * @param symbol the symbol to generate expectation files for * @throws IOException if there is any I/O error */ private void generateExpectationFiles(Symbol symbol) throws IOException { if (symbol.error_msg != null && !symbol.error_msg.isEmpty()) { generateErrorExpectationFile(symbol); } else { generateCodewordsExpectationFile(symbol); generatePngExpectationFile(symbol); } } /** * Generates the error expectation file for the specified symbol. * * @param symbol the symbol to generate the error expectation file for * @throws IOException if there is any I/O error */ private void generateErrorExpectationFile(Symbol symbol) throws IOException { if (!errorFile.exists()) { PrintWriter writer = new PrintWriter(errorFile); writer.println(symbol.error_msg); writer.close(); } } /** * Generates the codewords expectation file for the specified symbol. * * @param symbol the symbol to generate codewords for * @throws IOException if there is any I/O error */ private void generateCodewordsExpectationFile(Symbol symbol) throws IOException { if (!codewordsFile.exists()) { PrintWriter writer = new PrintWriter(codewordsFile); try { int[] codewords = symbol.getCodewords(); for (int codeword : codewords) { writer.println(codeword); } } catch (UnsupportedOperationException e) { for (String pattern : symbol.pattern) { writer.println(pattern); } } writer.close(); } } /** * Generates the image expectation file for the specified symbol. * * @param symbol the symbol to draw * @throws IOException if there is any I/O error */ private void generatePngExpectationFile(Symbol symbol) throws IOException { if (!pngFile.exists()) { BufferedImage img = draw(symbol); ImageIO.write(img, "png", pngFile); } } /** * Returns the integer contained in the specified string. If the string contains a tab character, it and everything after it * is ignored. * * @param s the string to extract the integer from * @return the integer contained in the specified string */ private static int getInt(String s) { int i = s.indexOf('\t'); if (i != -1) { s = s.substring(0, i); } return Integer.parseInt(s); } /** * Draws the specified symbol and returns the resultant image. * * @param symbol the symbol to draw * @return the resultant image */ private static BufferedImage draw(Symbol symbol) { int magnification = 10; int width = symbol.getWidth() * magnification; int height = symbol.getHeight() * magnification; BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY); Graphics2D g2d = img.createGraphics(); g2d.setPaint(Color.WHITE); g2d.fillRect(0, 0, width, height); Java2DRenderer renderer = new Java2DRenderer(g2d, magnification, Color.WHITE, Color.BLACK); renderer.render(symbol); g2d.dispose(); return img; } /** * Initializes the specified symbol using the specified properties, where keys are attribute names and values are attribute * values. * * @param symbol the symbol to initialize * @param properties the attribute names and values to set * @throws ReflectiveOperationException if there is any reflection error */ private static void setProperties(Symbol symbol, Map< String, String > properties) throws ReflectiveOperationException { for (Map.Entry< String, String > entry : properties.entrySet()) { String name = entry.getKey(); String value = entry.getValue(); String setterName = "set" + name.substring(0, 1).toUpperCase() + name.substring(1); Method setter = getMethod(symbol.getClass(), setterName); invoke(symbol, setter, value); } } /** * Returns the method with the specified name in the specified class, or throws an exception if the specified method cannot be * found. * * @param clazz the class to search in * @param name the name of the method to search for * @return the method with the specified name in the specified class */ private static Method getMethod(Class< ? > clazz, String name) { for (Method method : clazz.getMethods()) { if (method.getName().equals(name)) { return method; } } throw new RuntimeException("Unable to find method: " + name); } /** * Invokes the specified method on the specified object with the specified parameter. * * @param object the object to invoke the method on * @param setter the method to invoke * @param parameter the parameter to pass to the method * @throws ReflectiveOperationException if there is any reflection error * @throws IllegalArgumentException if the specified parameter is not valid */ @SuppressWarnings("unchecked") private static < E extends Enum< E >> void invoke(Object object, Method setter, Object parameter) throws ReflectiveOperationException, IllegalArgumentException { Class< ? > paramType = setter.getParameterTypes()[0]; if (String.class.equals(paramType)) { setter.invoke(object, parameter.toString()); } else if (boolean.class.equals(paramType)) { setter.invoke(object, Boolean.valueOf(parameter.toString())); } else if (int.class.equals(paramType)) { setter.invoke(object, Integer.parseInt(parameter.toString())); } else if (double.class.equals(paramType)) { setter.invoke(object, Double.parseDouble(parameter.toString())); } else if (Character.class.equals(paramType)) { setter.invoke(object, parameter.toString().charAt(0)); } else if (paramType.isEnum()) { Class< E > e = (Class< E >) paramType; setter.invoke(object, Enum.valueOf(e, parameter.toString())); } else { throw new RuntimeException("Unknown setter type: " + paramType); } } /** * Returns all .properties files in the specified directory, or an empty array if none are found. * * @param dir the directory to search in * @return all .properties files in the specified directory, or an empty array if none are found */ private static File[] getPropertiesFiles(String dir) { File[] files = new File(dir).listFiles(new FilenameFilter() { @Override public boolean accept(File dir, String name) { return name.endsWith(".properties"); } }); if (files != null) { return files; } else { return new File[0]; } } /** * Verifies that the specified images match. * * @param expected the expected image to check against * @param actual the actual image */ private static void assertEqual(BufferedImage expected, BufferedImage actual) { int w = expected.getWidth(); int h = expected.getHeight(); Assert.assertEquals("width", w, actual.getWidth()); Assert.assertEquals("height", h, actual.getHeight()); int[] expectedPixels = new int[w * h]; expected.getRGB(0, 0, w, h, expectedPixels, 0, w); int[] actualPixels = new int[w * h]; actual.getRGB(0, 0, w, h, actualPixels, 0, w); for (int i = 0; i < expectedPixels.length; i++) { int expectedPixel = expectedPixels[i]; int actualPixel = actualPixels[i]; if (expectedPixel != actualPixel) { int x = i % w; int y = i / w; throw new ComparisonFailure("pixel at " + x + ", " + y, toHexString(expectedPixel), toHexString(actualPixel)); } } } /** * Extracts test configuration properties from the specified properties file. A single properties file can contain * configuration properties for multiple tests. * * @param propertiesFile the properties file to read * @return the test configuration properties in the specified file * @throws IOException if there is an error reading the properties file */ private static List< Map< String, String > > readProperties(File propertiesFile) throws IOException { String content; try { byte[] bytes = Files.readAllBytes(propertiesFile.toPath()); content = replacePlaceholders(decode(bytes, UTF_8)); } catch (CharacterCodingException e) { throw new IOException("Invalid UTF-8 content in file " + propertiesFile.getAbsolutePath(), e); } String eol = System.lineSeparator(); String[] lines = content.split(eol); List< Map< String, String > > allProperties = new ArrayList<>(); Map< String, String > properties = new LinkedHashMap<>(); for (String line : lines) { if (line.isEmpty()) { // an empty line signals the start of a new test configuration within this single file if (!properties.isEmpty()) { allProperties.add(properties); properties = new LinkedHashMap<>(); } } else if (!line.startsWith("#")) { int index = line.indexOf('='); if (index != -1) { String name = line.substring(0, index); String value = line.substring(index + 1); properties.put(name, value); } else { throw new IOException(propertiesFile.getAbsolutePath() + ": found line without '=' character; unintentional newline?"); } } } if (!properties.isEmpty()) { allProperties.add(properties); } return allProperties; } /** * Equivalent to {@link String#String(byte[], Charset)}, except that encoding errors result in runtime errors instead of * silent character replacement. * * @param bytes the bytes to decode * @param charset the character set use to decode the bytes * @return the specified bytes, as a string * @throws CharacterCodingException if there is an error decoding the specified bytes */ private static String decode(byte[] bytes, Charset charset) throws CharacterCodingException { CharsetDecoder decoder = charset.newDecoder(); decoder.onMalformedInput(CodingErrorAction.REPORT); decoder.onUnmappableCharacter(CodingErrorAction.REPORT); CharBuffer chars = decoder.decode(ByteBuffer.wrap(bytes)); return chars.toString(); } /** * Replaces any special placeholders supported in test properties files with their raw values. * * @param s the string to check for placeholders * @return the specified string, with placeholders replaced */ private static String replacePlaceholders(String s) { return s.replaceAll("\\\\r", "\r") // "\r" -> CR .replaceAll("\\\\n", "\n"); // "\n" -> LF } /** * Finds all test resources and returns the information that JUnit needs to dynamically create the corresponding test cases. * * @return the test data needed to dynamically create the test cases * @throws IOException if there is an error reading a file */ @Parameters(name = "test {index}: {5}: {6}") public static List< Object[] > data() throws IOException { String filter = System.getProperty("okapi.symbol.test"); String backend = "uk.org.okapibarcode.backend"; Reflections reflections = new Reflections(backend); Set< Class< ? extends Symbol >> symbols = reflections.getSubTypesOf(Symbol.class); List< Object[] > data = new ArrayList<>(); for (Class< ? extends Symbol > symbol : symbols) { String symbolName = symbol.getSimpleName().toLowerCase(); if (filter == null || filter.equals(symbolName)) { String dir = "src/test/resources/" + backend.replace('.', '/') + "/" + symbolName; for (File file : getPropertiesFiles(dir)) { String fileBaseName = file.getName().replaceAll(".properties", ""); File codewordsFile = new File(file.getParentFile(), fileBaseName + ".codewords"); File pngFile = new File(file.getParentFile(), fileBaseName + ".png"); File errorFile = new File(file.getParentFile(), fileBaseName + ".error"); for (Map< String, String > properties : readProperties(file)) { data.add(new Object[] { symbol, properties, codewordsFile, pngFile, errorFile, symbolName, fileBaseName }); } } } } return data; } }